// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:async';
import 'dart:io';

import 'package:code_assets/code_assets.dart';
import 'package:dartdev/src/native_assets_bundling.dart';
import 'package:dartdev/src/sdk.dart';
import 'package:dartdev/src/utils.dart';
import 'package:data_assets/data_assets.dart';
import 'package:file/local.dart';
import 'package:hooks/hooks.dart';
import 'package:hooks_runner/hooks_runner.dart';
import 'package:logging/logging.dart';
import 'package:package_config/package_config.dart' as package_config;
import 'package:pub/pub.dart';
import 'package:yaml/yaml.dart' show loadYaml;

import 'core.dart';

class DartNativeAssetsBuilder {
  final Uri? pubspecUri;
  final Uri packageConfigUri;
  final package_config.PackageConfig packageConfig;
  final String runPackageName;
  final bool includeDevDependencies;
  final bool verbose;
  final bool dataAssetsExperimentEnabled;

  static const _fileSystem = LocalFileSystem();

  late final Future<PackageLayout> _packageLayout = () async {
    return PackageLayout.fromPackageConfig(
      _fileSystem,
      packageConfig,
      packageConfigUri,
      runPackageName,
      includeDevDependencies: includeDevDependencies,
    );
  }();

  late final _logger = Logger('')
    ..onRecord.listen((LogRecord record) {
      final levelValue = record.level.value;
      if (levelValue >= Level.SEVERE.value) {
        log.stderr(record.message);
      } else if (levelValue >= Level.WARNING.value ||
          verbose && levelValue >= Level.INFO.value) {
        log.stdout(record.message);
      } else {
        // Note, this is ignored by default.
        log.trace(record.message);
      }
    });

  late final Future<NativeAssetsBuildRunner> _nativeAssetsBuildRunner =
      () async {
    return NativeAssetsBuildRunner(
      // This always runs in JIT mode.
      dartExecutable: Uri.file(sdk.dart),
      logger: _logger,
      fileSystem: const LocalFileSystem(),
      packageLayout: await _packageLayout,
      userDefines: UserDefines(workspacePubspec: pubspecUri),
    );
  }();

  DartNativeAssetsBuilder({
    this.pubspecUri,
    required this.packageConfigUri,
    required this.packageConfig,
    required this.runPackageName,
    required this.includeDevDependencies,
    required this.verbose,
    required this.dataAssetsExperimentEnabled,
    Target? target,
  }) : target = target ?? Target.current;

  /// Compiles all native assets for host OS in JIT mode.
  ///
  /// If provided, only native assets of all transitive dependencies of
  /// [runPackageName] are built.
  Future<List<EncodedAsset>?> compileNativeAssetsJit() async {
    final buildResult = await _buildNativeAssetsShared(linkingEnabled: false);
    if (buildResult == null) return null;
    return buildResult.encodedAssets;
  }

  /// Compiles all native assets for host OS in JIT mode, and creates the
  /// native assets yaml file.
  ///
  /// If provided, only native assets of all transitive dependencies of
  /// [runPackageName] are built.
  ///
  /// Used in `dart run` and `dart test`.
  Future<Uri?> compileNativeAssetsJitYamlFile() async {
    final assets = await compileNativeAssetsJit();
    if (assets == null) return null;

    final dartToolUri = Directory.current.uri.resolve('.dart_tool/');
    final outputUri = dartToolUri.resolve('native_assets/');
    await Directory.fromUri(outputUri).create(recursive: true);

    final kernelAssets = await bundleNativeAssets(
      assets,
      target,
      outputUri,
      relocatable: false,
    );

    return await writeNativeAssetsYaml(
      kernelAssets,
      dartToolUri,
      header: '''# Native assets mapping for host OS in JIT mode.
# Generated by dartdev and package:hooks_runner.
''',
    );
  }

  /// Check whether there are build hooks in the project.
  ///
  /// Fine to run together with the other methods, this is stateful, no work is
  /// duplicated.
  Future<bool> hasHooks() async {
    final builder = await _nativeAssetsBuildRunner;
    final packageNames = await builder.packagesWithBuildHooks();
    return packageNames.isNotEmpty;
  }

  Future<bool> warnOnNativeAssets() async {
    final builder = await _nativeAssetsBuildRunner;
    final packageNames = await builder.packagesWithBuildHooks();
    if (packageNames.isEmpty) return false;
    log.stderr(
      'Package(s) $packageNames require the native assets feature to be enabled. '
      'Enable native assets with `--enable-experiment=native-assets`.',
    );
    return true;
  }

  late final _extensions = [
    CodeAssetExtension(
      targetOS: target.os,
      linkModePreference: LinkModePreference.dynamic,
      targetArchitecture: target.architecture,
      macOS: _macOSConfig,
      cCompiler: _cCompilerConfig,
    ),
    if (dataAssetsExperimentEnabled) DataAssetsExtension(),
  ];

  Future<BuildResult?> _buildNativeAssetsShared({
    required bool linkingEnabled,
  }) async {
    final builder = await _nativeAssetsBuildRunner;
    final buildResult = await builder.build(
      extensions: _extensions,
      linkingEnabled: linkingEnabled,
    );
    if (buildResult.isFailure) return null;
    return buildResult.success;
  }

  Future<BuildResult?> buildNativeAssetsAOT() {
    return _buildNativeAssetsShared(linkingEnabled: true);
  }

  Future<LinkResult?> linkNativeAssetsAOT({
    required String? recordedUsagesPath,
    required BuildResult buildResult,
  }) async {
    final builder = await _nativeAssetsBuildRunner;
    final linkResult = await builder.link(
      extensions: _extensions,
      resourceIdentifiers:
          recordedUsagesPath != null ? Uri.file(recordedUsagesPath) : null,
      buildResult: buildResult,
    );
    if (linkResult.isFailure) return null;
    return linkResult.success;
  }

  final Target target;

  late final _macOSConfig = target.os == OS.macOS
      ? MacOSCodeConfig(targetVersion: minimumSupportedMacOSVersion)
      : null;

  late final _cCompilerConfig = _getCCompilerConfig(target.os);

  CCompilerConfig? _getCCompilerConfig(OS targetOS) {
    // Specifically for running our tests on Dart CI with the test runner, we
    // recognize specific variables to setup the C Compiler configuration.
    final env = Platform.environment;
    final cc = env['DART_HOOK_TESTING_C_COMPILER__CC'];
    final ar = env['DART_HOOK_TESTING_C_COMPILER__AR'];
    final ld = env['DART_HOOK_TESTING_C_COMPILER__LD'];
    final envScript = env['DART_HOOK_TESTING_C_COMPILER__ENV_SCRIPT'];
    final envScriptArgs =
        env['DART_HOOK_TESTING_C_COMPILER__ENV_SCRIPT_ARGUMENTS']
            ?.split(' ')
            .map((arg) => arg.trim())
            .where((arg) => arg.isNotEmpty)
            .toList();
    if (cc != null && ar != null && ld != null) {
      return CCompilerConfig(
        archiver: Uri.file(ar),
        compiler: Uri.file(cc),
        linker: Uri.file(ld),
        windows: targetOS == OS.windows
            ? WindowsCCompilerConfig(
                developerCommandPrompt: envScript == null
                    ? null
                    : DeveloperCommandPrompt(
                        script: Uri.file(envScript),
                        arguments: envScriptArgs ?? [],
                      ),
              )
            : null,
      );
    }
    return null;
  }

  /// Runs `pub get` if no package config can be found.
  ///
  /// Returns `null` if no package config can be found, even after pub get.
  static Future<Uri?> ensurePackageConfig(Uri uri) async {
    var packageConfig = await _findPackageConfigUri(uri);
    // TODO(https://github.com/dart-lang/package_config/issues/126): Use
    // package config resolution from package:package_config.
    if (packageConfig == null) {
      final pubspecMaybe = await findPubspec(uri);
      if (pubspecMaybe != null) {
        // Silently run `pub get`, this is what would happen in
        // `getExecutableForCommand` later.
        try {
          await ensurePubspecResolved(pubspecMaybe.resolve('.').toFilePath());
        } on ResolutionFailedException catch (e) {
          stderr.writeln(e.message);
          return null;
        }
        packageConfig = await _findPackageConfigUri(uri);
      } else {
        return null;
      }
    }
    return packageConfig;
  }

  /// Tries to load the package config.
  ///
  /// Returns null and writes to stderr if the package config is malformed.
  static Future<package_config.PackageConfig?> loadPackageConfig(
      Uri packageConfigUri) async {
    try {
      return await package_config.loadPackageConfigUri(packageConfigUri);
    } on FormatException catch (e) {
      // This can be thrown if the package_config.json is malformed or has
      // duplicate entries.
      log.stderr(
        'Error encountered while parsing '
        '${packageConfigUri.toFilePath()}: ${e.message}.',
      );
      return null;
    }
  }

  /// Finds the package config uri.
  ///
  /// Returns `null` if no package config can be found.
  // TODO(https://github.com/dart-lang/package_config/issues/126): Expose this
  // logic in package:package_config.
  static Future<Uri?> _findPackageConfigUri(Uri uri) async {
    while (true) {
      final packageConfig =
          File.fromUri(uri.resolve('.dart_tool/package_config.json'));
      final packageGraph =
          File.fromUri(uri.resolve('.dart_tool/package_graph.json'));
      if (await packageConfig.exists() && await packageGraph.exists()) {
        return packageConfig.uri;
      }
      final parent = uri.resolve('..');
      if (parent == uri) {
        return null;
      }
      uri = parent;
    }
  }

  static Future<Uri?> findPubspec(Uri uri) async {
    while (true) {
      final candidate = uri.resolve('pubspec.yaml');
      if (await File.fromUri(candidate).exists()) {
        return candidate;
      }
      final parent = uri.resolve('..');
      if (parent == uri) {
        return null;
      }
      uri = parent;
    }
  }

  static Future<Uri?> findWorkspacePubspec(Uri? workspacePackageConfig) async {
    if (workspacePackageConfig == null) {
      return null;
    }
    final candidate = workspacePackageConfig.resolve('../pubspec.yaml');
    if (File.fromUri(candidate).existsSync()) {
      return candidate;
    }
    return null;
  }

  /// Tries to find the package name that [uri] is in.
  ///
  /// Returns `null` if package cannnot be determined.
  static Future<String?> findRootPackageName(Uri uri) async {
    final pubspecUri = await findPubspec(uri);
    if (pubspecUri == null) {
      return null;
    }
    final pubspecFile = File.fromUri(pubspecUri);
    final contents = await pubspecFile.readAsString();
    final pubspec = loadYaml(contents);
    return pubspec['name'];
  }
}
